import ExpoModulesTestCore

@testable import ExpoModulesCore

class ClassDefinitionSpec: ExpoSpec {
  override class func spec() {
    describe("basic") {
      it("factory returns a definition") {
        let klass = Class("") {}
        expect(klass).to(beAnInstanceOf(ClassDefinition.self))
      }

      it("has a name") {
        let expoClassName = "ExpoClass"
        let klass = Class(expoClassName) {}
        expect(klass.name) == expoClassName
      }

      it("is without native constructor") {
        let klass = Class("") {}
        expect(klass.constructor).to(beNil())
      }

      it("has native constructor") {
        let klass = Class("") { Constructor {} }
        expect(klass.constructor).notTo(beNil())
      }

      it("ignores constructor as function") {
        let klass = Class("") { Constructor {} }
        expect(klass.functions["constructor"]).to(beNil())
      }

      it("builds a class") {
        let appContext = AppContext.create()
        let klass = Class("") {}
        let object = try klass.build(appContext: appContext)

        expect(object.hasProperty("prototype")) == true
        expect(object.getProperty("prototype").kind) == .object
      }
    }

    describe("module") {
      let appContext = AppContext.create()
      let runtime = try! appContext.runtime

      beforeSuite {
        class ClassTestModule: Module {
          func definition() -> ModuleDefinition {
            Name("ClassTest")

            Class("MyClass") {
              Constructor {}

              Function("myFunction") {
                return "foobar"
              }

              Property("foo") {
                return "bar"
              }
            }
          }
        }
        appContext.moduleRegistry.register(moduleType: ClassTestModule.self, name: "ClassTest")
      }

      it("is a function") {
        let klass = try runtime.eval("expo.modules.ClassTest.MyClass")
        expect(klass.isFunction()) == true
      }

      it("has a name") {
        let klass = try runtime.eval("expo.modules.ClassTest.MyClass.name")
        expect(klass.getString()) == "MyClass"
      }

      it("has a prototype") {
        let prototype = try runtime.eval("expo.modules.ClassTest.MyClass.prototype")
        expect(prototype.isObject()) == true
      }

      it("has keys in prototype") {
        let prototypeKeys = try runtime.eval("Object.keys(expo.modules.ClassTest.MyClass.prototype)")
          .getArray()
          .map { $0.getString() }

        expect(prototypeKeys).to(contain("myFunction"))
        expect(prototypeKeys).notTo(contain("__native_constructor__"))
      }

      it("is an instance of") {
        let isInstanceOf = try runtime.eval([
          "myObject = new expo.modules.ClassTest.MyClass()",
          "myObject instanceof expo.modules.ClassTest.MyClass",
        ])

        expect(isInstanceOf.getBool()) == true
      }

      it("defines properties on initialization") {
        // The properties are not specified in the prototype, but defined during initialization.
        let object = try runtime.eval("new expo.modules.ClassTest.MyClass()").asObject()
        expect(object.getPropertyNames()).to(contain("foo"))
        expect(object.getProperty("foo").getString()) == "bar"
      }
    }

    describe("class with associated type") {
      let appContext = AppContext.create()
      let runtime = try! appContext.runtime

      beforeSuite {
        appContext.moduleRegistry.register(moduleType: ModuleWithCounterClass.self, name: "TestModule")
      }
      it("is defined") {
        let isDefined = try runtime.eval("'Counter' in expo.modules.TestModule")

        expect(isDefined.getBool()) == true
      }
      it("creates shared object") {
        let jsObject = try runtime.eval("new expo.modules.TestModule.Counter(0)").getObject()
        let nativeObject = appContext.sharedObjectRegistry.toNativeObject(jsObject)

        expect(nativeObject).notTo(beNil())
      }
      it("registers shared object") {
        let oldSize = appContext.sharedObjectRegistry.size
        try runtime.eval("object = new expo.modules.TestModule.Counter(0)")

        expect(appContext.sharedObjectRegistry.size) == oldSize + 1
      }
      it("calls function with owner") {
        try runtime.eval([
          "object = new expo.modules.TestModule.Counter(0)",
          "object.increment(1)",
        ])
        // no expectations, just checking if it doesn't fail
      }
      it("creates with initial value") {
        let initialValue = Int.random(in: 1..<100)
        let value = try runtime.eval([
          "object = new expo.modules.TestModule.Counter(\(initialValue))",
          "object.getValue()",
        ])

        expect(value.kind) == .number
        expect(value.getInt()) == initialValue
      }
      it("gets shared object value") {
        let value = try runtime.eval([
          "object = new expo.modules.TestModule.Counter(0)",
          "object.getValue()",
        ])

        expect(value.kind) == .number
        expect(value.isNumber()) == true
      }
      it("changes shared object") {
        try runtime.eval("object = new expo.modules.TestModule.Counter(0)")
        let incrementBy = Int.random(in: 1..<100)
        let value = try runtime.eval("object.getValue()").asInt()
        let newValue = try runtime.eval([
          "object.increment(\(incrementBy))",
          "object.getValue()",
        ])

        expect(newValue.kind) == .number
        expect(newValue.getInt()) == value + incrementBy
      }

      it("gets value from the dynamic property") {
        let initialValue = Int.random(in: 1..<100)
        let value = try runtime.eval([
          "object = new expo.modules.TestModule.Counter(\(initialValue))",
          "object.currentValue"
        ])

        expect(value.kind) == .number
        expect(value.getInt()) == initialValue
      }

      it("initializes the shared object from native") {
        let initialValue = Int.random(in: 1..<100)
        let value = try runtime.eval("expo.modules.TestModule.newCounter(\(initialValue))")

        expect(value.kind) == .object
        expect(value.getObject().getProperty("currentValue").getInt()) == initialValue
      }

      it("initializes the shared object from static function") {
        let initialValue = Int.random(in: 1..<100)
        let value = try runtime.eval("expo.modules.TestModule.Counter.create(\(initialValue))")

        expect(value.kind) == .object
        expect(value.getObject().getProperty("currentValue").getInt()) == initialValue
      }

      it("initializes the shared object from static async function") {
        let initialValue = Int.random(in: 1..<100)
        try runtime
          .eval(
            "expo.modules.TestModule.Counter.createAsync(\(initialValue)).then((result) => { globalThis.result = result; })"
          )

        expect(safeBoolEval("globalThis.result != null")).toEventually(beTrue(), timeout: .milliseconds(4000))
        let object = try runtime.eval("object = globalThis.result")
        
        expect(object.kind) == .object
        expect(object.getObject().getProperty("currentValue").getInt()) == initialValue
      }
      // For async tests, this is a safe way to repeatedly evaluate JS
      // and catch both Swift and ObjC exceptions
      func safeBoolEval(_ js: String) -> Bool {
        var result = false
        do {
          try EXUtilities.catchException {
            guard let jsResult = try? runtime.eval(js) else {
              return
            }
            result = jsResult.getBool()
          }
        } catch {
          return false
        }
        return result
      }
    }

    describe("constructor error handling") {
      let appContext = AppContext.create()
      let runtime = try! appContext.runtime

      beforeSuite {
        class ErrorTestModule: Module {
          func definition() -> ModuleDefinition {
            Name("ErrorTest")

            Class("FailingClass") {
              Constructor { (shouldFail: Bool) in
                if shouldFail {
                  throw TestCodedException()
                }
                return Counter(initialValue: 0)
              }

              Function("test") {
                return "success"
              }
            }
          }
        }
        appContext.moduleRegistry.register(moduleType: ErrorTestModule.self, name: "ErrorTest")
      }

      it("exceptions are in the correct format") {
        expect {
          try runtime.eval("new expo.modules.ErrorTest.FailingClass(true)")
        }.to(throwError { (error: JavaScriptEvalException) in
          let reason = error.param.userInfo["message"] as? String ?? ""
          expect(reason).to(contain("Calling the 'constructor' function has failed"))
          expect(reason).to(contain("→ Caused by:"))
          expect(reason).to(contain("This is a test Exception with a code"))
        })
      }

      it("check error codes from coded exceptions") {
        let errorCode = try runtime.eval([
          "try { new expo.modules.ErrorTest.FailingClass(true) } catch (error) { error.code }"
        ]).getString()
        expect(errorCode) == "E_TEST_CODE"
      }

      it("succeeds when constructor does not throw") {
        let result = try runtime.eval("new expo.modules.ErrorTest.FailingClass(false)")
        expect(result.kind) == .object
        expect(result.getObject().hasProperty("test")) == true
      }

      it("can call methods on successfully constructed objects") {
        let result = try runtime.eval([
          "obj = new expo.modules.ErrorTest.FailingClass(false)",
          "obj.test()"
        ])
        expect(result.getString()) == "success"
      }
    }
  }
}

/**
 A module that exposes a Counter class with an associated shared object class.
 */
fileprivate final class ModuleWithCounterClass: Module {
  func definition() -> ModuleDefinition {
    Name("TestModule")

    Function("newCounter") { (initialValue: Int) in
      return Counter(initialValue: initialValue)
    }

    Class(Counter.self) {
      Constructor { (initialValue: Int) in
        return Counter(initialValue: initialValue)
      }
      
      StaticFunction("create") { (initialValue: Int) in
        return Counter(initialValue: initialValue)
      }

      StaticAsyncFunction("createAsync") { (initialValue: Int, p: Promise) in
        p.resolve(Counter(initialValue: initialValue))
      }

      Function("increment") { (counter, value: Int) in
        counter.increment(by: value)
      }
      Function("getValue") { counter in
        return counter.currentValue
      }

      Property("currentValue") { counter in
        return counter.currentValue
      }
    }
  }
}

/**
 A shared object class that stores some native value and can be used as an associated type of the JS class.
 */
fileprivate final class Counter: SharedObject {
  var currentValue = 0

  init(initialValue: Int = 0) {
    self.currentValue = initialValue
  }

  func increment(by value: Int = 1) {
    currentValue += value
  }
}
